Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 0e0732a1d1122220f5aa0b6285329b130f1c900f


Parents : af2eff2
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-13T21:14:31-05:00

feat(page-handling): update nomadnet browser renderer and page management by adding support for .md, .txt, and .html extensions, implement filename normalization, and improve error handling for unsupported extensions

Changes
Diff

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index abd4d6ba..e0252c39 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -7399,7 +7399,10 @@ class ReticulumMeshChat:
return web.json_response(
{"message": "Page name is required"}, status=400
)
- saved_name = node.add_page(name, content)
+ try:
+ saved_name = node.add_page(name, content)
+ except ValueError as e:
+ return web.json_response({"message": str(e)}, status=400)
return web.json_response({"name": saved_name, "message": "Page saved"})
@routes.get("/api/v1/page-nodes/{node_id}/pages/{page_name}")
@@ -9531,6 +9534,10 @@ class ReticulumMeshChat:
response.headers["Content-Type"] = "application/wasm"
elif path.endswith(".html"):
response.headers["Content-Type"] = "text/html; charset=utf-8"
+ elif path.endswith(".md"):
+ response.headers["Content-Type"] = "text/markdown; charset=utf-8"
+ elif path.endswith(".txt"):
+ response.headers["Content-Type"] = "text/plain; charset=utf-8"
elif path.endswith(".opus"):
response.headers["Content-Type"] = "audio/opus"
elif path.endswith(".ogg"):

diff --git a/meshchatx/src/backend/page_node.py b/meshchatx/src/backend/page_node.py
index 6c0dcb58..523a24fb 100644
--- a/meshchatx/src/backend/page_node.py
+++ b/meshchatx/src/backend/page_node.py
@@ -9,6 +9,8 @@ request/response with specific path conventions).
Clients link to the destination and call link.request("/page/name.mu")
to fetch a page, or /file/name for files.
+
+Supported page filename extensions are ``.mu``, ``.md``, ``.txt``, and ``.html``.
"""
import json
@@ -21,6 +23,27 @@ APP_NAME = "nomadnetwork"
ASPECT = "node"
DEFAULT_INDEX = "index.mu"
+ALLOWED_PAGE_EXTENSIONS = frozenset({".mu", ".md", ".txt", ".html"})
+
+
+def normalize_page_filename(name: str) -> str:
+ """Return a safe basename with an allowed extension. Unknown extensions raise ValueError."""
+ name = os.path.basename((name or "").strip())
+ if not name or name in (".", ".."):
+ raise ValueError("page name is required")
+ lower = name.lower()
+ for ext in ALLOWED_PAGE_EXTENSIONS:
+ if lower.endswith(ext):
+ return name
+ if "." in name:
+ raise ValueError("unsupported page extension")
+ return f"{name}.mu"
+
+
+def is_allowed_page_filename(name: str) -> bool:
+ lower = os.path.basename(name or "").lower()
+ return any(lower.endswith(ext) for ext in ALLOWED_PAGE_EXTENSIONS)
+
class PageNode:
"""A single page-serving node on the Reticulum mesh."""
@@ -134,8 +157,11 @@ class PageNode:
if not os.path.isdir(self.pages_dir):
return
for fname in os.listdir(self.pages_dir):
- if os.path.isfile(os.path.join(self.pages_dir, fname)):
- self._register_page_handler(fname)
+ if not os.path.isfile(os.path.join(self.pages_dir, fname)):
+ continue
+ if not is_allowed_page_filename(fname):
+ continue
+ self._register_page_handler(fname)
def _register_existing_files(self):
"""Scan files directory and register a handler for each file."""
@@ -230,10 +256,8 @@ class PageNode:
return responder
def add_page(self, name, content):
- """Write a Micron page and register its request handler."""
- name = os.path.basename(name)
- if not name.endswith(".mu"):
- name += ".mu"
+ """Write a page file and register its request handler."""
+ name = normalize_page_filename(name)
page_path = os.path.join(self.pages_dir, name)
if isinstance(content, str):
content = content.encode("utf-8")
@@ -245,7 +269,10 @@ class PageNode:
def remove_page(self, name):
"""Remove a page and deregister its request handler."""
- name = os.path.basename(name)
+ try:
+ name = normalize_page_filename(name)
+ except ValueError:
+ return False
page_path = os.path.join(self.pages_dir, name)
if os.path.isfile(page_path):
os.remove(page_path)
@@ -261,11 +288,15 @@ class PageNode:
f
for f in os.listdir(self.pages_dir)
if os.path.isfile(os.path.join(self.pages_dir, f))
+ and is_allowed_page_filename(f)
)
def get_page_content(self, name):
"""Read and return a page's content."""
- name = os.path.basename(name)
+ try:
+ name = normalize_page_filename(name)
+ except ValueError:
+ return None
page_path = os.path.join(self.pages_dir, name)
if not os.path.isfile(page_path):
return None

diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
index d134334a..106e07d8 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
@@ -321,12 +321,12 @@
</div>
</div>
<!-- eslint-disable vue/no-v-html -->
- <pre
+ <div
v-else
- v-memo="[renderedNodePageHtml]"
- class="h-full break-words whitespace-pre-wrap"
+ v-memo="[renderedNodePageHtml, nodePagePath, isShowingNodePageSource]"
+ :class="nomadPageContentClasses"
v-html="renderedNodePageHtml"
- ></pre>
+ ></div>
<!-- eslint-enable vue/no-v-html -->
</div>
@@ -396,6 +396,7 @@
<script>
import MicronParser from "../../js/MicronParser";
+import { renderNomadPageByPath } from "../../js/NomadPageRenderer";
import DialogUtils from "../../js/DialogUtils";
import WebSocketConnection from "../../js/WebSocketConnection";
import NomadNetworkSidebar from "./NomadNetworkSidebar.vue";
@@ -526,6 +527,31 @@ export default {
}
return this.renderPageContent(this.nodePagePath, this.nodePageContent);
},
+ nomadPageContentClasses() {
+ if (!this.nodePagePath || this.isShowingNodePageSource) {
+ return ["h-full", "break-words", "whitespace-pre-wrap", "text-gray-100"];
+ }
+ const [p] = this.nodePagePath.split("`");
+ const pl = (p || "").toLowerCase();
+ const isRich = pl.endsWith(".mu") || pl.endsWith(".md") || pl.endsWith(".html");
+ const isHtml = pl.endsWith(".html");
+ const isMd = pl.endsWith(".md");
+ const classes = ["h-full", "break-words"];
+ if (isRich) {
+ classes.push("nomad-page-rich");
+ } else {
+ classes.push("whitespace-pre-wrap");
+ }
+ if (isHtml) {
+ classes.push("nomad-page-html-host");
+ } else {
+ classes.push("text-gray-100");
+ }
+ if (isMd) {
+ classes.push("nomad-markdown-host");
+ }
+ return classes;
+ },
},
watch: {
renderedNodePageHtml(newVal, oldVal) {
@@ -1240,18 +1266,11 @@ export default {
renderPageContent(path, content) {
// render page content if we aren't viewing source
if (!this.isShowingNodePageSource) {
- // check if page url ends with .mu but remove page data first
// address:/page/index.mu`Data=123
const [pagePathWithoutData] = path.split("`");
-
- // convert micron to html if page ends with .mu extension
- if (pagePathWithoutData.endsWith(".mu")) {
- const muParser = new MicronParser();
- return muParser.convertMicronToHtml(content, this.pagePartials);
- }
+ return renderNomadPageByPath(pagePathWithoutData, content, this.pagePartials, MicronParser);
}
- // otherwise, we will just serve the raw content, making sure to prevent injecting html
return content
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
@@ -1835,4 +1854,83 @@ pre.text-wrap > div > :last-child {
color: inherit;
box-sizing: content-box;
}
+
+.nomad-markdown-host {
+ font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
+}
+
+.nomad-markdown-host .nomad-markdown {
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+.nomad-markdown-host .nomad-markdown table {
+ white-space: normal;
+}
+
+.nomad-markdown-host .nomad-markdown h1 {
+ font-size: 1.875rem;
+ line-height: 2.25rem;
+ font-weight: 700;
+ margin: 0.75rem 0 0.5rem;
+}
+
+.nomad-markdown-host .nomad-markdown h2 {
+ font-size: 1.5rem;
+ line-height: 2rem;
+ font-weight: 700;
+ margin: 0.65rem 0 0.45rem;
+}
+
+.nomad-markdown-host .nomad-markdown h3 {
+ font-size: 1.25rem;
+ line-height: 1.75rem;
+ font-weight: 600;
+ margin: 0.55rem 0 0.4rem;
+}
+
+.nomad-markdown-host .nomad-markdown h4 {
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+ font-weight: 600;
+ margin: 0.5rem 0 0.35rem;
+}
+
+.nomad-markdown-host .nomad-markdown h5,
+.nomad-markdown-host .nomad-markdown h6 {
+ font-size: 1rem;
+ line-height: 1.5rem;
+ font-weight: 600;
+ margin: 0.45rem 0 0.3rem;
+}
+
+.nomad-markdown-host .nomad-markdown p {
+ margin: 0.4rem 0;
+}
+
+.nomad-markdown-host .nomad-markdown ul,
+.nomad-markdown-host .nomad-markdown ol {
+ margin: 0.4rem 0;
+ padding-left: 1.5rem;
+}
+
+.nomad-markdown-host .nomad-markdown blockquote {
+ margin: 0.5rem 0;
+ padding-left: 0.75rem;
+ border-left: 3px solid rgb(107 114 128);
+}
+
+.nomad-markdown-host .nomad-markdown pre {
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ overflow-x: auto;
+}
+
+.nomad-page-html-host {
+ font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
+}
+
+.nomad-page-html-host .nomad-html-root {
+ color: rgb(229 231 235);
+}
</style>

diff --git a/meshchatx/src/frontend/js/MicronParser.js b/meshchatx/src/frontend/js/MicronParser.js
index 9dbb7bc4..9327d4f9 100644
--- a/meshchatx/src/frontend/js/MicronParser.js
+++ b/meshchatx/src/frontend/js/MicronParser.js
@@ -51,6 +51,29 @@ export default class MicronParser extends BaseMicronParser {
return false;
}
+ /**
+ * When false, forceMonospace can render a whole word in one span (Latin/Cyrillic/etc.),
+ * avoiding one DOM node per character (critical for large pages and resize performance).
+ */
+ static lineNeedsPerCharCells(line) {
+ if (!line) {
+ return false;
+ }
+ for (const char of line) {
+ if (MicronParser.isWideMonospaceCell(char)) {
+ return true;
+ }
+ const cp = char.codePointAt(0);
+ if (cp >= 0x2500 && cp <= 0x257f) {
+ return true;
+ }
+ if (cp >= 0x2580 && cp <= 0x259f) {
+ return true;
+ }
+ }
+ return false;
+ }
+
static stripOverlayStyles(html) {
if (typeof html !== "string") return html;
const dangerousProps = ["zindex", "inset", "top", "left", "right", "bottom", "transform"];
@@ -121,6 +144,14 @@ export default class MicronParser extends BaseMicronParser {
row-gap: 0;
gap: 0;
}
+ .Mu-mnt-group {
+ display: inline;
+ font-family: ui-monospace, monospace, "Courier New", monospace;
+ white-space: pre;
+ text-decoration: inherit;
+ vertical-align: baseline;
+ line-height: 1.25;
+ }
`;
document.head.appendChild(styleEl);
}
@@ -365,6 +396,12 @@ export default class MicronParser extends BaseMicronParser {
}
forceMonospace(line) {
+ if (line == null || line === "") {
+ return "";
+ }
+ if (!MicronParser.lineNeedsPerCharCells(line)) {
+ return "<span class='Mu-mnt-group'>" + escapeHtmlForFallback(line) + "</span>";
+ }
let out = "";
let charArr;
try {
@@ -378,7 +415,7 @@ export default class MicronParser extends BaseMicronParser {
}
for (let char of charArr) {
const cellClass = MicronParser.isWideMonospaceCell(char) ? "Mu-mnt-full" : "Mu-mnt";
- out += "<span class='" + cellClass + "'>" + char + "</span>";
+ out += "<span class='" + cellClass + "'>" + escapeHtmlForFallback(char) + "</span>";
}
return out;
}

diff --git a/meshchatx/src/frontend/js/NomadPageRenderer.js b/meshchatx/src/frontend/js/NomadPageRenderer.js
new file mode 100644
index 00000000..1f8e7361
--- /dev/null
+++ b/meshchatx/src/frontend/js/NomadPageRenderer.js
@@ -0,0 +1,214 @@
+import DOMPurify from "dompurify";
+import { marked } from "marked";
+
+marked.setOptions({
+ gfm: true,
+ breaks: true,
+});
+
+const FORBID_TAGS = [
+ "script",
+ "iframe",
+ "object",
+ "embed",
+ "link",
+ "base",
+ "meta",
+ "form",
+ "input",
+ "button",
+ "textarea",
+ "select",
+ "option",
+ "video",
+ "audio",
+ "source",
+ "track",
+ "picture",
+];
+
+function escapeHtmlText(text) {
+ return String(text)
+ .replace(/&/g, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&#039;");
+}
+
+export function escapeNomadPlainText(content) {
+ return `<div class="whitespace-pre-wrap text-gray-100">${escapeHtmlText(content)}</div>`;
+}
+
+const NOMAD_HTML_ROOT_CLASS = "nomad-html-root";
+
+function normalizeMarkdownInput(md) {
+ if (md == null || md === "") {
+ return "";
+ }
+ let s = String(md);
+ s = s.replace(/\r\n/g, "\n");
+ // CommonMark requires a space after # for ATX headings; "#Title" is not a heading
+ s = s.replace(/^(\s{0,3})(\#{1,6})([^\s#])/gm, "$1$2 $3");
+ return s;
+}
+
+function rewriteCssBodyHtmlSelectors(css) {
+ if (!css) {
+ return "";
+ }
+ let s = stripExternalFromCss(css);
+ s = s.replace(/\bhtml\s*\{/g, `.${NOMAD_HTML_ROOT_CLASS} {`);
+ s = s.replace(/\bbody\s*\{/g, `.${NOMAD_HTML_ROOT_CLASS} {`);
+ s = s.replace(/\bhtml\s*,/g, `.${NOMAD_HTML_ROOT_CLASS},`);
+ s = s.replace(/\bbody\s*,/g, `.${NOMAD_HTML_ROOT_CLASS},`);
+ s = s.replace(/\bhtml\s+body\b/g, `.${NOMAD_HTML_ROOT_CLASS}`);
+ return s;
+}
+
+function stripExternalFromCss(css) {
+ if (!css) {
+ return "";
+ }
+ let s = css;
+ s = s.replace(/@import\s+[^;]+;/gi, "");
+ s = s.replace(/@import\s+url\s*\([^)]+\)\s*;?/gi, "");
+ s = s.replace(/expression\s*\(/gi, "blocked(");
+ s = s.replace(/javascript\s*:/gi, "blocked:");
+ s = s.replace(/-moz-binding/gi, "blocked-binding");
+ s = s.replace(/url\s*\(\s*["']?(?:https?:|\/\/)/gi, "url(blocked:");
+ return s;
+}
+
+function isAllowedNomadHref(href) {
+ if (href == null || href === "") {
+ return false;
+ }
+ const h = href.trim();
+ if (h.startsWith("#")) {
+ return true;
+ }
+ if (h.startsWith(":")) {
+ return true;
+ }
+ if (/^[a-f0-9]{32}:/i.test(h)) {
+ return true;
+ }
+ if (h.startsWith("/page/") || h.startsWith("/file/")) {
+ return true;
+ }
+ return false;
+}
+
+function isAllowedImgSrc(src) {
+ if (src == null || src === "") {
+ return false;
+ }
+ const s = src.trim();
+ return /^data:image\/(png|gif|jpeg|jpg|webp|svg\+xml)/i.test(s);
+}
+
+let nomadPurifyHooksInstalled = false;
+
+function ensureNomadPurifyHooks() {
+ if (nomadPurifyHooksInstalled) {
+ return;
+ }
+ nomadPurifyHooksInstalled = true;
+ DOMPurify.addHook("uponSanitizeElement", (node) => {
+ if (node.nodeName === "STYLE" && node.textContent) {
+ node.textContent = stripExternalFromCss(node.textContent);
+ }
+ });
+ DOMPurify.addHook("afterSanitizeAttributes", (node) => {
+ if (node.nodeName === "A" && node.hasAttribute("href")) {
+ const h = node.getAttribute("href");
+ if (!isAllowedNomadHref(h)) {
+ node.removeAttribute("href");
+ }
+ }
+ if (node.nodeName === "IMG" && node.hasAttribute("src")) {
+ const s = node.getAttribute("src");
+ if (!isAllowedImgSrc(s)) {
+ node.removeAttribute("src");
+ }
+ }
+ if (node.hasAttributes) {
+ const attrs = node.attributes;
+ for (let i = attrs.length - 1; i >= 0; i--) {
+ const a = attrs[i].name;
+ if (a && a.toLowerCase().startsWith("on")) {
+ node.removeAttribute(a);
+ }
+ }
+ }
+ });
+}
+
+function basePurifyConfig() {
+ return {
+ FORBID_TAGS,
+ ADD_TAGS: ["style"],
+ ADD_ATTR: ["class", "id", "title", "colspan", "rowspan", "align", "start"],
+ };
+}
+
+export function sanitizeNomadHtmlFragment(html) {
+ ensureNomadPurifyHooks();
+ return DOMPurify.sanitize(html, {
+ ...basePurifyConfig(),
+ WHOLE_DOCUMENT: false,
+ });
+}
+
+export function sanitizeNomadHtmlDocument(html) {
+ ensureNomadPurifyHooks();
+ let bodyMarkup = html;
+ try {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(html, "text/html");
+ const styles = [...doc.querySelectorAll("style")]
+ .map((el) => rewriteCssBodyHtmlSelectors(el.textContent || ""))
+ .filter(Boolean)
+ .join("\n");
+ const body = doc.body;
+ if (body) {
+ bodyMarkup = (styles ? `<style>${styles}</style>` : "") + body.innerHTML;
+ }
+ } catch {
+ bodyMarkup = html;
+ }
+ const wrapped = `<div class="${NOMAD_HTML_ROOT_CLASS}">${bodyMarkup}</div>`;
+ return DOMPurify.sanitize(wrapped, {
+ ...basePurifyConfig(),
+ WHOLE_DOCUMENT: false,
+ });
+}
+
+export function renderNomadMarkdown(markdown) {
+ const raw = marked.parse(normalizeMarkdownInput(markdown ?? ""));
+ const inner = sanitizeNomadHtmlFragment(raw);
+ return `<div class="nomad-markdown">${inner}</div>`;
+}
+
+export function renderNomadHtmlPage(html) {
+ return sanitizeNomadHtmlDocument(html);
+}
+
+export function renderNomadPageByPath(pagePathWithoutData, content, pagePartials, MicronParserClass) {
+ const p = (pagePathWithoutData || "").toLowerCase();
+ if (p.endsWith(".mu")) {
+ const muParser = new MicronParserClass();
+ return muParser.convertMicronToHtml(content, pagePartials);
+ }
+ if (p.endsWith(".md")) {
+ return renderNomadMarkdown(content);
+ }
+ if (p.endsWith(".html")) {
+ return renderNomadHtmlPage(content);
+ }
+ if (p.endsWith(".txt")) {
+ return escapeNomadPlainText(content);
+ }
+ return escapeHtmlText(content);
+}

diff --git a/tests/backend/test_fuzzing.py b/tests/backend/test_fuzzing.py
index de9799bd..add445c0 100644
--- a/tests/backend/test_fuzzing.py
+++ b/tests/backend/test_fuzzing.py
@@ -25,6 +25,7 @@ from meshchatx.src.backend.nomadnet_utils import (
convert_nomadnet_field_data_to_map,
convert_nomadnet_string_data_to_map,
)
+from meshchatx.src.backend.page_node import normalize_page_filename
from meshchatx.src.backend.telemetry_utils import Telemeter
@@ -91,6 +92,16 @@ def test_nomadnet_field_conversion_fuzzing(field_data):
)
+@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
+@given(name=st.text(min_size=0, max_size=500))
+def test_normalize_page_filename_fuzzing(name):
+ """Fuzz mesh server page filename normalization."""
+ try:
+ normalize_page_filename(name)
+ except ValueError:
+ pass
+
+
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(app_data_base64=st.one_of(st.none(), st.text(min_size=0, max_size=1000)))
def test_display_name_parsing_fuzzing(app_data_base64):

diff --git a/tests/backend/test_page_node.py b/tests/backend/test_page_node.py
index 17f080de..98b08502 100644
--- a/tests/backend/test_page_node.py
+++ b/tests/backend/test_page_node.py
@@ -5,6 +5,8 @@ from unittest.mock import MagicMock, patch
import pytest
+from meshchatx.src.backend.page_node import normalize_page_filename
+
@pytest.fixture
def node_dir():
@@ -172,6 +174,39 @@ class TestPageNodePages:
node.setup()
assert node.add_page("test.mu", "x") == "test.mu"
+ def test_add_page_accepts_md_txt_html(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ assert node.add_page("notes.md", "# x") == "notes.md"
+ assert node.add_page("readme.txt", "plain") == "readme.txt"
+ assert node.add_page("static.html", "<p>a</p>") == "static.html"
+ assert os.path.isfile(os.path.join(node.pages_dir, "notes.md"))
+ assert "/page/notes.md" in node._registered_page_paths
+
+ def test_add_page_rejects_unknown_extension(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ with pytest.raises(ValueError):
+ node.add_page("bad.exe", "x")
+
+ def test_remove_page_invalid_extension_returns_false(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ assert node.remove_page("bad.exe") is False
+
+ def test_get_page_content_invalid_extension_returns_none(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ assert node.get_page_content("bad.exe") is None
+
+ def test_setup_skips_unlisted_page_files(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ os.makedirs(node.pages_dir, exist_ok=True)
+ with open(os.path.join(node.pages_dir, "junk.exe"), "wb") as f:
+ f.write(b"x")
+ node.setup()
+ assert "/page/junk.exe" not in node._registered_page_paths
+
def test_add_page_registers_handler(self, node_dir, mock_rns):
node = _make_node(node_dir, mock_rns)
node.setup()
@@ -357,6 +392,19 @@ class TestPageNodeLinkCallbacks:
assert len(node.active_links) == 0
+class TestNormalizePageFilename:
+ def test_accepts_allowed_extensions(self):
+ assert normalize_page_filename("a.mu") == "a.mu"
+ assert normalize_page_filename("b.md") == "b.md"
+ assert normalize_page_filename("c.txt") == "c.txt"
+ assert normalize_page_filename("d.html") == "d.html"
+ assert normalize_page_filename("x") == "x.mu"
+
+ def test_rejects_bad_extension(self):
+ with pytest.raises(ValueError):
+ normalize_page_filename("x.exe")
+
+
class TestPageNodeEdgeCases:
def test_add_page_before_running(self, node_dir, mock_rns):
node = _make_node(node_dir, mock_rns)

diff --git a/tests/e2e/conversations-nomad-visualiser.spec.js b/tests/e2e/conversations-nomad-visualiser.spec.js
index 7df1cd19..1f1f6e32 100644
--- a/tests/e2e/conversations-nomad-visualiser.spec.js
+++ b/tests/e2e/conversations-nomad-visualiser.spec.js
@@ -19,9 +19,9 @@ test.describe("Messages (conversations)", () => {
await page.goto("/#/messages");
await page.getByText("Announces", { exact: true }).first().click();
await expect(page.getByPlaceholder(/Search .* recent announces/i)).toBeVisible({ timeout: 20000 });
- await expect(
- page.getByText(/No Peers Discovered|Waiting for someone to announce/i).first()
- ).toBeVisible({ timeout: 20000 });
+ await expect(page.getByText(/No Peers Discovered|Waiting for someone to announce/i).first()).toBeVisible({
+ timeout: 20000,
+ });
});
test("sidebar navigates between Nomad Network and Messages", async ({ page }) => {

diff --git a/tests/frontend/MessagesPageSidebarIntegration.test.js b/tests/frontend/MessagesPageSidebarIntegration.test.js
index f10b9f96..77e2bc03 100644
--- a/tests/frontend/MessagesPageSidebarIntegration.test.js
+++ b/tests/frontend/MessagesPageSidebarIntegration.test.js
@@ -27,7 +27,7 @@ vi.mock("@/js/Utils", () => ({
const ConversationViewerStub = {
name: "ConversationViewer",
- template: "<div class=\"cv-stub\"></div>",
+ template: '<div class="cv-stub"></div>',
methods: {
markConversationAsRead: vi.fn(),
},
@@ -46,11 +46,9 @@ describe("MessagesPage with MessagesSidebar integration", () => {
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/config")
return Promise.resolve({ data: { config: { lxmf_address_hash: "my-hash" } } });
- if (url === "/api/v1/lxmf/conversations")
- return Promise.resolve({ data: { conversations: [] } });
+ if (url === "/api/v1/lxmf/conversations") return Promise.resolve({ data: { conversations: [] } });
if (url === "/api/v1/announces") return Promise.resolve({ data: { announces: [] } });
- if (url === "/api/v1/lxmf/conversation-pins")
- return Promise.resolve({ data: { peer_hashes: [] } });
+ if (url === "/api/v1/lxmf/conversation-pins") return Promise.resolve({ data: { peer_hashes: [] } });
if (url === "/api/v1/lxmf/folders") return Promise.resolve({ data: [] });
return Promise.resolve({ data: {} });
});
@@ -111,8 +109,7 @@ describe("MessagesPage with MessagesSidebar integration", () => {
},
});
if (url === "/api/v1/announces") return Promise.resolve({ data: { announces: [] } });
- if (url === "/api/v1/lxmf/conversation-pins")
- return Promise.resolve({ data: { peer_hashes: [] } });
+ if (url === "/api/v1/lxmf/conversation-pins") return Promise.resolve({ data: { peer_hashes: [] } });
if (url === "/api/v1/lxmf/folders") return Promise.resolve({ data: [] });
return Promise.resolve({ data: {} });
});

diff --git a/tests/frontend/NomadPageRenderer.test.js b/tests/frontend/NomadPageRenderer.test.js
new file mode 100644
index 00000000..8ebffee2
--- /dev/null
+++ b/tests/frontend/NomadPageRenderer.test.js
@@ -0,0 +1,93 @@
+import { describe, expect, it } from "vitest";
+import MicronParser from "../../meshchatx/src/frontend/js/MicronParser";
+import {
+ escapeNomadPlainText,
+ renderNomadHtmlPage,
+ renderNomadMarkdown,
+ renderNomadPageByPath,
+ sanitizeNomadHtmlFragment,
+} from "../../meshchatx/src/frontend/js/NomadPageRenderer";
+
+describe("NomadPageRenderer", () => {
+ it("renders markdown to safe HTML without script tags", () => {
+ const html = renderNomadMarkdown("# Title\n\n[x](javascript:alert(1))");
+ expect(html).toContain("Title");
+ expect(html.toLowerCase()).not.toContain("<script");
+ expect(html).not.toContain("javascript:");
+ });
+
+ it("normalizes ATX headings when space is missing after hashes", () => {
+ const html = renderNomadMarkdown("#Hello\n\n##There\n\n####Deep");
+ expect(html).toContain("<h1");
+ expect(html).toContain("Hello");
+ expect(html).toContain("<h2");
+ expect(html).toContain("<h4");
+ });
+
+ it("wraps markdown in nomad-markdown container", () => {
+ const html = renderNomadMarkdown("x");
+ expect(html).toContain('class="nomad-markdown"');
+ });
+
+ it("strips external http links from markdown output", () => {
+ const html = renderNomadMarkdown("[a](https://evil.example/)");
+ expect(html).not.toContain("evil.example");
+ });
+
+ it("allows nomad-style href on links after sanitization", () => {
+ const html = renderNomadMarkdown("[local](:page/other.mu)");
+ expect(html).toContain(":page/other.mu");
+ });
+
+ it("sanitizes HTML documents and removes script", () => {
+ const html = renderNomadHtmlPage(
+ "<!DOCTYPE html><html><head><style>body{color:red}</style></head><body><p>Hi</p><script>x</script></body></html>"
+ );
+ expect(html.toLowerCase()).not.toContain("<script");
+ expect(html).toContain("Hi");
+ expect(html).toContain("nomad-html-root");
+ expect(html).toContain(".nomad-html-root");
+ expect(html).toContain("color:red");
+ });
+
+ it("removes iframe and external stylesheet references from HTML", () => {
+ const html = renderNomadHtmlPage(
+ '<html><head><link rel="stylesheet" href="https://evil.example/s.css"/></head><body><iframe src="https://x"></iframe><p>x</p></body></html>'
+ );
+ expect(html.toLowerCase()).not.toContain("iframe");
+ expect(html).not.toContain("evil.example");
+ });
+
+ it("escapes plain text pages", () => {
+ const out = escapeNomadPlainText("<script>bad</script>");
+ expect(out).not.toContain("<script>");
+ expect(out).toContain("&lt;script&gt;");
+ });
+
+ it("renderNomadPageByPath picks format by extension", () => {
+ const dest = "a".repeat(32);
+ const muHtml = renderNomadPageByPath(`${dest}:/page/x.mu`, "Hello mesh", {}, MicronParser);
+ expect(typeof muHtml).toBe("string");
+ expect(muHtml.length).toBeGreaterThan(0);
+ expect(renderNomadPageByPath("/page/a.md", "# T", {}, MicronParser)).toContain("T");
+ expect(renderNomadPageByPath("/page/a.txt", "a<b>", {}, MicronParser)).toContain("&lt;");
+ expect(renderNomadPageByPath("/page/a.html", '<p class="x">z</p>', {}, MicronParser)).toContain("z");
+ });
+
+ it("sanitizeNomadHtmlFragment handles arbitrary strings without throwing", () => {
+ expect(() => sanitizeNomadHtmlFragment("<div>ok</div>")).not.toThrow();
+ });
+
+ it("fuzzing: handles random strings in markdown and HTML sanitization", () => {
+ for (let i = 0; i < 200; i++) {
+ let s = "";
+ const len = Math.floor(Math.random() * 800);
+ for (let j = 0; j < len; j++) {
+ s += String.fromCharCode(Math.floor(Math.random() * 65536));
+ }
+ expect(() => renderNomadMarkdown(s)).not.toThrow();
+ expect(() => sanitizeNomadHtmlFragment(s)).not.toThrow();
+ expect(() => renderNomadHtmlPage(s)).not.toThrow();
+ }
+ });
+});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────